Note
Click here to download the full example code
local proportions¶
category proportions can vary in space: a vertical proportion curve, areal proportion maps, and their product as 3D
proportions that sum to 1 in every cell. passed to SIS as proportions=, they keep each lithology where the data
put it; check_realizations then compares realizations with and without them.
import sys
from pathlib import Path
HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import INK, map_axes, save
the tailings of categories, as a first campaign would see them: every third of the 107 sonic holes, composited to 1 m, each composite taking the lithology that covers most of it. clay forms the foundation under the tailings, sand settled in the north and slimes in the south. the facility sits on rolling ground, so the height of a composite above the original ground orders the layers better than its elevation.
data = bt.datasets.tailings_reprocessing()
holes = bt.Drillholes.from_tables(data, intervals="lithology")
composites = holes.composite(1.0, [], categories=["LITH"])
ids = composites["HOLE_ID"]
composites = composites.filter(np.isin(ids, sorted(set(ids))[::3]))
ground, top = data["original_ground"], data["surface"]
xyz = composites.coords
height = composites.z - ground.sample(xyz[:, :2], "Z")
scheme = bt.Categories(["CAP", "SAND", "SLIME", "CLAY"], colors=["#b8b8b8", "#d9b56c", "#5b7a99", "#7a4f35"])
codes = scheme.encode(composites["LITH"]).astype(int)
weights = bt.cell_declustering(xyz[:, :2], height, cell_size=100.0).weights
print(f"{len(composites)} composites, {height.min():.1f} to {height.max():.1f} m above the original ground")
Out:
583 composites, -2.2 to 24.4 m above the original ground
vertical proportion curve¶
vertical_proportions takes the weighted share of each category in slices of height, here 1 m slices of the
height above the original ground, passed as elevation. the declustering weights keep densely drilled parts from
dominating, and scheme fixes the order and names. the result is a Table with one row per slice: its center, its
weight and one column per category.
curve = bt.vertical_proportions(
composites, "LITH", size=1.0, elevation=height, weights=weights, scheme=scheme
)
levels = curve["elevation"]
print(curve.column_names)
fig, ax = plt.subplots(figsize=(4.2, 4.2))
left = np.zeros(len(levels))
for name, color in zip(scheme.names, scheme.colors, strict=True):
right = left + curve[name]
ax.fill_betweenx(levels, left, right, color=color, step="mid", lw=0)
left = right
ax.set(xlim=(0, 1), xlabel="Proportion", ylabel="Height above original ground (m)", title="Vertical curve")
bt.plot.category_legend(scheme, ax, loc="upper left", bbox_to_anchor=(1.01, 1))
save(fig, "vertical")

Out:
['elevation', 'weight', 'CAP', 'SAND', 'SLIME', 'CLAY']
clay fills the meters under the original ground; sand and slimes share the tailings, and the cap appears at the heights the facility's top reaches. the cap is a cover tied to the surface, so a depth below the surface would suit it better than a height above the base.
areal proportion maps¶
detrend with categorical=True in plan gives the areal proportions: the declustered average of each category's
indicator under a gaussian kernel, with the bandwidth chosen by leave-one-out error. with scheme, its columns
follow the curve's.
areal, _ = bt.detrend(
xyz[:, :2],
composites["LITH"],
bandwidth=[60, 90, 130, 200],
weights=weights,
categorical=True,
scheme=scheme,
)
print(f"bandwidth {areal.bandwidth:.0f} m")
low, high = top.bounds
extent = (low[0], high[0], low[1], high[1])
plan = areal.predict(top.coords[:, :2])
fig, axes = plt.subplots(1, 3, figsize=(12, 3.3), sharey=True, layout="constrained")
for ax, name in zip(axes, ["SAND", "SLIME", "CLAY"], strict=True):
shown = ax.imshow(top.grid(plan[name])[0], origin="lower", extent=extent, vmin=0, vmax=1, cmap="Greys")
ax.scatter(*xyz[:, :2].T, s=1, color=INK)
map_axes(ax, name.capitalize())
for ax in axes[1:]:
ax.set_ylabel("")
fig.colorbar(shown, ax=axes, label="Areal proportion", shrink=0.8)
save(fig, "areal")

Out:
bandwidth 90 m
sand dominates the north and slimes the south; clay, under the whole facility, is even.
proportions in 3D¶
the model is a BlockModel of 20 × 20 × 1 m cells from 3 m under the original ground to the tailings surface.
in each cell, combine_proportions multiplies the curve at the cell's height by the areal proportions at its
position, divides by the global proportions (the curve's weighted mean) and rescales to sum 1. where the areal map
equals the global proportions, the curve comes back unchanged; where it has more sand, each height above the clay
gets more sand. the same call gives the proportions at the composites.
plan20 = bt.BlockModel(origin=(4945.0, 1945.0), size=(20.0, 20.0), count=(41, 28))
base_at = ground.sample(plan20.coords[:, :2], "Z") - 3.0
top_at = top.sample(plan20.coords[:, :2], "Z")
z0 = np.floor(np.nanmin(base_at))
full = bt.BlockModel(
origin=(4945.0, 1945.0, z0), size=(20.0, 20.0, 1.0), count=(41, 28, int(np.ceil(np.nanmax(top_at) - z0)))
)
xy = full.coords[:, :2]
model = full.filter((full.z > plan20.sample(xy, base_at)) & (full.z < plan20.sample(xy, top_at)))
cells = model.coords
cell_height = model.z - ground.sample(cells[:, :2], "Z")
at_cells = bt.combine_proportions(cells, curve, areal.predict(cells[:, :2]), elevation=cell_height)
at_data = bt.combine_proportions(xyz, curve, areal.predict(xyz[:, :2]), elevation=height)
rows = np.column_stack([at_cells[name] for name in scheme.names])
print(
f"{len(model)} cells, proportions {rows.min():.2f} to {rows.max():.2f}, sums {rows.sum(axis=1).min():.12f}"
)
Out:
17346 cells, proportions 0.00 to 1.00, sums 1.000000000000
SIS with and without local proportions¶
each category gets one spherical indicator variogram with a sill of p(1 − p), 150 m across and 4 m vertical. plain
SIS krigs each indicator by ordinary kriging from its neighbors. given proportions= at fit and simulate, as
a Table or an (n, k) array, it krigs the indicator minus its local proportion by simple kriging instead, which
pulls each node towards the proportions of its own cell.
shares = scheme.shares(codes, weights=weights)
variograms = [bt.Variogram([("spherical", p * (1 - p), 150.0)], ratios=(1.0, 4.0 / 150.0)) for p in shares]
search = bt.Search(radius=150.0, max_samples=16, ratios=(1.0, 0.05))
plain = bt.SIS(variograms, search).fit(xyz, codes)
local = bt.SIS(variograms, search).fit(xyz, codes, proportions=at_data)
runs = {
"Global": plain.simulate(model, n=20, seed=7, keep=True),
"Local proportions": local.simulate(model, n=20, seed=7, keep=True, proportions=at_cells),
}
a north-south section through the middle of the facility shows the difference. far from the holes, plain SIS falls back on the global proportions and scatters clay through the tailings; with local proportions the clay stays at the base, where the curve puts it.
cmap, norm = bt.plot.category_colors(scheme)
section = np.abs(model.x - 5355.0) < 10.0
fig, axes = plt.subplots(2, 1, figsize=(10, 4.4), sharex=True, layout="constrained")
for ax, (title, summary) in zip(axes, runs.items(), strict=True):
y, z = model.y[section], model.z[section]
ax.scatter(y, z, c=summary.realizations[0][section], cmap=cmap, norm=norm, marker="s", s=14, linewidths=0)
ax.set(title=f"{title}, realization 1", ylabel="Elevation (m)")
axes[1].set_xlabel("Northing (m)")
bt.plot.category_legend(scheme, fig, loc="outside right upper")
save(fig, "sections")

checks¶
check_realizations (realization checks) compares the category proportions of each realization with the declustered
data's. both runs come within five points of them, the local one with more cap, which the curve spreads over each
height the facility's top reaches. the global shares hide where each category went. the share of clay in each meter
of height shows it, and only the run with local proportions follows the curve.
fig, axes = plt.subplots(1, 3, figsize=(12, 3.8), layout="constrained")
for ax, (title, summary) in zip(axes[:2], runs.items(), strict=True):
check = bt.check_realizations(model, summary, xyz, codes, weights=weights)
bt.plot.histogram_reproduction(check, ax=ax)
ax.set_xticks(range(len(scheme)), scheme.names)
ax.set(title=title, xlabel="")
print(
f"{title:18}",
np.round(check.proportions.mean(axis=0), 3),
"data",
np.round(check.data_proportions, 3),
)
slices = np.floor(cell_height)
for (title, summary), style in zip(runs.items(), ["--", "-"], strict=True):
clay = [(summary.realizations[:, slices == s] == 3).mean() for s in np.unique(slices)]
axes[2].plot(clay, np.unique(slices) + 0.5, style, color=scheme.colors[3], label=title)
axes[2].plot(curve["CLAY"], levels, "o", color=INK, ms=3, label="Vertical curve")
axes[2].set(xlabel="Clay proportion", ylabel="Height above original ground (m)", title="Clay by height")
axes[2].legend()
save(fig, "checks")

Out:
Global [0.031 0.41 0.389 0.169] data [0.028 0.439 0.374 0.158]
Local proportions [0.044 0.397 0.386 0.173] data [0.028 0.439 0.374 0.158]
Total running time of the script: ( 0 minutes 1.909 seconds)
Download Python source code: example_07_04.py